Skip to content

fix(gates): read a terminal dialog reply as one multi-line turn - #510

Merged
Jason Robert (jrob5756) merged 6 commits into
microsoft:mainfrom
throup:fix/dialog-multiline-terminal-input
Sep 9, 2026
Merged

Jason Robert (jrob5756) merged 6 commits into
microsoft:mainfrom
throup:fix/dialog-multiline-terminal-input

Conversation

@throup

@throup Chris Throup (throup) commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #509.

What changed

Dialog mode was the only free-text human-input surface that could not accept a multi-line answer, so pasting a block into a terminal dialog dispatched every line as its own turn. The human gate's reader is extracted to read_multiline_lines(console, *, sentinel) in human.py and the dialog's terminal turn reads through it, so a paste arrives as one turn with its newlines intact and there is one reader rather than two. HumanGateHandler._read_multiline delegates with its existing . sentinel, leaving that gate behaviourally unchanged.

Off a tty (a pipe or CI) the single-line path stays, with one deliberate change: a blank line is now skipped instead of dispatched as an empty turn. The web dashboard is untouched — it returns at dialog.py:207, before either reader, and has always received whole messages.

Design decisions worth a reviewer's attention

Both submission guards compare stripped text. The reader drops trailing newlines but deliberately keeps a whitespace-only line (stripping it would eat the closing indentation of a pasted code block), so an exact == "" check would dispatch " " as a turn and turn Ctrl-D into a submission. not user_input.strip() / not text.strip() match _is_dismiss, which already normalises this way.

_reads_multiline_turn() states the tty rule once. The reader and the banner's sentinel hint both consult it, so the banner cannot advertise a keystroke the reader ignores. Both branches of the reader gate now have a test.

A dismiss keyword now needs the sentinel too. Requiring /send to submit made the banner's own "Say done or /done when finished" inert on a tty — done is content until the turn is sent, so the dialog never exited. _dismiss_instruction() states that rule once beside _reads_multiline_turn(); the banner and the failure-recovery notice both render it, and both directions are pinned. Off a tty the sentence is unchanged from main.

Ctrl-D now costs two keystrokes if you have typed something. A terminal's EOF does not persist, so on main a single Ctrl-D raised out of Prompt.ask and ended the dialog. Now it submits what is typed and returns to a fresh prompt, so leaving a half-written reply takes a second Ctrl-D — and the first one sends that reply. Ctrl-D on an empty prompt still exits in one keystroke. This is the price of "an EOF that terminates a paste submits it", which is the behaviour the fix is for; it is now stated in the CHANGELOG and the docs rather than left for a user to discover.

sentinel is keyword-only and required. read_multiline_lines is public and the two gates use different sentinels; a positional default meant read_multiline_lines(console) was silently valid and would truncate prose at any lone ..

/send for the dialog, . kept for the human gate. A lone . is likelier to be prose in a conversational reply — but . already terminates multi-paragraph free text in questions without complaint, so consistency is a fair counter-argument. Alternatives if you prefer: . for both (a reply containing a lone . truncates), /send for both (a behaviour change for existing gate users), or configurable (disproportionate). It is a single constant that the banner and every test reference rather than a literal, so switching is a one-line edit — say which you want.

The change is three commits: the guard fix, the test coverage plus the reader's tightened contract, and the docs correction. Each builds and tests green on its own.

Verification

  • Full suite: 8326 passed. Three failures reproduce identically on a clean upstream/main checkout (chmod 0o000 permission tests and a case-insensitive-filesystem test that do not hold on macOS APFS); confirmed pre-existing rather than assumed.
  • ruff check, ruff format --check, ty check, test_markup_guards.py — clean.
  • Every new guard is mutation-tested: reverting it fails the suite. Details below.
Mutation-test evidence for each new guard

Run over tests/test_gates tests/test_engine/test_dialog_integration.py tests/test_cli/test_markup_injection.py tests/test_engine/test_resume.py — the set used in review, which previously passed 138/138 against every one of these mutations:

Mutation Before Now
baseline 138 passed 155 passed
drop _reads_multiline_turn() half of the reader gate 138 passed 2 failed
drop prompt_text is None half of the reader gate 138 passed 1 failed
empty guard back to user_input == "" 138 passed 1 failed
Ctrl-D guard back to not text 138 passed 1 failed
sentinel loses its .strip() 138 passed 3 failed
delete the Ctrl-D guard entirely hung 2 failed
banner advertises the hint unconditionally 1 failed 1 failed
reader strips all trailing whitespace, not just newlines 138 passed 1 failed
non-tty banner wording drifts to the singular 138 passed 1 failed
read_on_daemon_thread swapped for asyncio.to_thread 138 passed 1 failed
banner names done without the sentinel 138 passed 1 failed
.strip() added to the returned turn text 138 passed 1 failed
sentinel reverts to a positional default 138 passed 1 failed
StopIteration re-added to the reader's except 138 passed 1 failed

The last row is the bounded-EOF change: side_effect=EOFError() re-raises forever, so a spinning loop used to hang the suite instead of failing it. The replacement raises after 10 reads with a named assertion.

Behaviour before and after, driving the real DialogHandler with only the input source mocked:

Input on a tty Before After
" " then /send 1 turn, DialogMessage(content=' ') 0 turns
" " then Ctrl-D submitted the whitespace, then dismissed 0 turns, dismissed
"a", "b" then /send (unchanged) 1 turn, 'a\nb'
blank line, piped (non-tty) 1 turn with empty content 0 turns
Corrections to the previous revision's description and docs

Three claims in the earlier description and docs were wrong, and are corrected here rather than restated:

"Ctrl-C dismisses rather than propagating" was false and is removed from the CHANGELOG and docs. CPython runs signal handlers on the main thread only and the read is on a daemon thread, so a real SIGINT never reaches that except. Verified by sending an actual SIGINT to a child blocked in the reader on a pty: the await raised CancelledError and KeyboardInterrupt escaped asyncio.run. The except clause is correct for exceptions the reader itself raises, so it stays; the test is renamed to test_reader_exception_dismisses_rather_than_crashing_the_dialog with a docstring saying explicitly that it does not cover Ctrl-C.

"Off a tty the single-line path is unchanged" was false. Prompt.ask returns "" for a blank line and the empty guard sits above the tty branch. Measured: upstream/main dispatched that blank line as a turn with empty content; this skips it. Now stated in the CHANGELOG, since that path is the contract for anyone driving a dialog from CI.

Grouping the web dashboard with "a pipe, CI" was wrong — it implies the dashboard hits the Prompt.ask fallback and cannot take a multi-line message. It returns before either reader. Fixed in both the CHANGELOG and the docs bullet.

The claim that every new test was confirmed to fail against the unpatched gate was wrong, and the correction is the substantive one. Re-checked with the reviewer's method (reverting only _get_user_input): only 1 of 5 genuinely failed. The original check reverted the whole dialog.py, which also removed its import sys, so three tests failed on patch(...sys.stdin.isatty) raising AttributeError — a harness failure that looked like a behavioural one. Those tests are now either strengthened until they do fail against the unpatched code, or their docstrings say plainly that they pin behaviour preserved by the extraction rather than guarding the fix.

The dialog gate read each reply with single-line `Prompt.ask`, so pasting a
block of text into an interactive terminal dispatched every line as its own
turn. A three-line paste became three separate questions to the model, each
answered against a fragment of the intended message, and the paste's trailing
newline added a fourth turn with empty content.

The human gate already had a multi-line reader for exactly this shape, so the
loop is extracted to `read_multiline_lines(console, sentinel)` in human.py and
the dialog's main turn reads through it. `HumanGateHandler._read_multiline`
delegates with its historical `.` sentinel, so that gate's behaviour is
unchanged.

The reader returns `(text, hit_eof)` rather than a bare string because the two
EOF cases are different answers: an EOF that terminates a paste should submit
the accumulated content, while an EOF with nothing accumulated is a deliberate
Ctrl-D and dismisses. Collapsing them would make the dismissal branch
unreachable, since the reader converts EOF into a returned string.

The dialog's sentinel is `/send` rather than the human gate's `.` because a
lone `.` is a plausible line of prose in a free-form reply. Because that makes
`/send` load-bearing for submitting a turn, `_display_dialog_start` advertises
it — gated on the same `sys.stdin.isatty()` condition as the reader, since off
a tty the turn falls back to `Prompt.ask` and the sentinel does nothing.

Off a tty (a pipe, CI, or the web dashboard, which returns via
`_web_handle_dialog` and never renders this banner) the single-line path is
untouched.

Verified: tests/test_gates 69 passed; full suite 8308 passed with three
failures that reproduce identically on an unpatched checkout (chmod 0o000 and
case-sensitivity tests that do not hold on this filesystem). Each new test
fails against the unpatched gate. ruff check, ruff format --check and ty are
clean.
@throup

Copy link
Copy Markdown
Contributor Author

Chris Throup (Chris Throup (@throup)) please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.

@microsoft-github-policy-service agree [company="{your company}"]

Options:

  • (default - no company specified) I have sole ownership of intellectual property rights to my Submissions and I am not making Submissions in the course of work for my employer.
@microsoft-github-policy-service agree
  • (when company given) I am making Submissions in the course of work for my employer (or my employer has intellectual property rights in my Submissions by contract or applicable law). I have permission from my employer to make Submissions and enter into this Agreement on behalf of my employer. By signing below, the defined term “You” includes me and my employer.
@microsoft-github-policy-service agree company="Microsoft"

Contributor License Agreement

@microsoft-github-policy-service agree company="Too Good To Go"

@jrob5756 Jason Robert (jrob5756) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blocking issues here, both in the same file and both about the same root cause: the new guards check user_input == "" but the reader only strips trailing newlines, not trailing whitespace. That lets a whitespace-only reply slip through the empty-submission guard and flips the intent of Ctrl-D after whitespace from "dismiss" to "submit." The second blocking issue is that the isatty() branch deciding which reader runs has no test in either direction, so either half of that condition can be deleted with the full suite still green. Neither is a large fix, but both need to land before merge.

Blocking

  • src/conductor/gates/dialog.py:282 — whitespace bypasses the empty-submission guard; Ctrl-D after whitespace submits instead of dismissing
  • src/conductor/gates/dialog.py:768 — the isatty() gate that picks the reader has no coverage on either branch

The rest are recommended: a CHANGELOG/docs accuracy pass (Ctrl-C doesn't actually dismiss, the non-tty path did change, the dashboard is mischaracterized), a StopIteration handler in production code that exists only to satisfy a mock, missing tests for the empty-guard and the sentinel's whitespace tolerance, a sentinel default that contradicts the PR's own documentation, a docstring that promises stronger normalization than the code does, a banner template held together by an unenforced arity match between a hint fragment and its args, and three new tests that pass against the unpatched code despite the PR description's claim that all of them were confirmed to fail first.

Comment thread src/conductor/gates/dialog.py Outdated
result.user_dismissed = True
break

if user_input == "":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING

The new guard is an exact user_input == "" check, but human.py:88 only strips trailing newlines ("\n".join(lines).rstrip("\n")), not trailing whitespace. A buffer of spaces or tabs survives as a truthy string and slips past both new guards.

Traced end-to-end through handle_dialog on the tty path with only input() mocked:

  • Typing " " then /sendexecute_dialog_turn(user_message=" ") gets awaited and a DialogMessage(role='user', content=' ') is recorded — exactly the case the CHANGELOG claims is fixed.
  • Typing " " then Ctrl-D → hit_eof=True but text is still truthy, so the if hit_eof and not text branch at line 776 ("the user is leaving, not pasting") never fires. The whitespace gets dispatched as a turn and then the dialog dismisses. The user pressed Ctrl-D to leave and got a paste submission instead.

This isn't cosmetic — engine/workflow.py splices result.messages verbatim into the agent's re-execution guidance, so the agent gets re-run believing the user replied with whitespace. Nothing is logged on either path. _is_dismiss already normalizes with .strip().lower() elsewhere in this file, so these two guards are the outliers.

Suggested change
if user_input == "":
if not user_input.strip():
continue

Same fix needed at line 776 (if hit_eof and not text.strip():), the docstring at 765-766 should say "nothing but whitespace accumulated" instead of "nothing accumulated," and it's worth a regression test with side_effect=[" ", EOFError()] asserting execute_dialog_turn.assert_not_awaited().

Comment thread src/conductor/gates/dialog.py Outdated
accumulated content rather than dismissing; an EOF with nothing
accumulated is a deliberate Ctrl-D and still returns None.
"""
if prompt_text is None and sys.stdin.isatty():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING

if prompt_text is None and sys.stdin.isatty(): is the single condition choosing between two very different readers, and mutation testing shows the suite doesn't care which half you delete. Ran tests/test_gates plus tests/test_engine/test_dialog_integration.py, tests/test_cli/test_markup_injection.py, and tests/test_engine/test_resume.py (138 tests):

  • Drop the isatty() half → 138 passed. The multi-line reader would now activate under a pipe or in CI, which is the exact failure this gate exists to prevent.
  • Drop the prompt_text is None half → also 138 passed. The yes/no confirmation prompt at line 351 would start requiring /send after "yes" on every interactive run.

The cause: lines 781-789 (the Prompt.ask fallback) never execute in this repo's test suite. Every pre-existing dialog test patches handler._get_user_input wholesale, and every new test runs with isatty=True. Coverage confirms it — Missing … 781-789. The human gate already has the mirror test for its own equivalent condition at tests/test_gates/test_human.py:679; this needs the same pair here.

@pytest.mark.asyncio
async def test_non_tty_main_turn_uses_the_single_line_prompt(self) -> None:
    """Off a tty the conversational turn must stay on Prompt.ask, never raw input()."""
    with (
        patch("conductor.gates.dialog.sys.stdin.isatty", return_value=False),
        patch("conductor.gates.dialog.Prompt.ask", side_effect=["piped answer", "done"]) as ask,
        patch("builtins.input", side_effect=AssertionError("must not read raw stdin")),
    ):
        ...
    assert ask.call_count == 2

@pytest.mark.asyncio
async def test_confirmation_prompt_stays_single_line_on_a_tty(self) -> None:
    """`prompt_text` is a yes/no question -- it must not require the /send sentinel."""
    with (
        patch("conductor.gates.dialog.sys.stdin.isatty", return_value=True),
        patch("conductor.gates.dialog.Prompt.ask", return_value="yes") as ask,
        patch("builtins.input", side_effect=AssertionError("must not read multi-line")),
    ):
        answer = await handler._get_user_input(prompt_text=styled("[bold]Continue?[/bold]"))
    assert answer == "yes"
    ask.assert_called_once()

Comment thread CHANGELOG.md Outdated
so internal newlines survive and a paste is a single message; an empty
submission is no longer dispatched as a turn. Ctrl-D (Ctrl-Z then Enter on
Windows) submits what has been typed and dismisses the dialog when nothing
has, and Ctrl-C dismisses rather than propagating. The dialog uses `/send`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RECOMMENDED

"Ctrl-C dismisses rather than propagating" isn't true, and I don't think the test can fail even if it were wrong. A real Ctrl-C can't reach the except (EOFError, KeyboardInterrupt) at dialog.py:774: CPython only runs signal handlers on the main thread, PEP 475 silently retries the interrupted read, and the daemon thread blocked in input() never sees the signal. asyncio.Runner on 3.12+ handles SIGINT by cancelling the main task instead, so what actually surfaces at the await is CancelledError — which isn't in the catch tuple, correctly.

Verified this by sending a real SIGINT to a child process blocked in read_multiline_lines on a pty: the await raised CancelledError and KeyboardInterrupt escaped asyncio.run. There's no SIGINT handler anywhere in src/conductor, so Ctrl-C still tears the run down exactly like before.

tests/test_gates/test_dialog.py:844 (test_ctrl_c_dismisses_rather_than_propagating) only passes because patch("builtins.input", side_effect=KeyboardInterrupt()) raises inside the worker thread — the one route a genuine SIGINT can't take. That's the kind of test that gets cited later as proof this works when it doesn't.

Anyone who reads this changelog line and presses Ctrl-C expecting to close the dialog and keep the run going will instead kill the run and lose the session.

Drop "and Ctrl-C dismisses rather than propagating" from this entry, and fix the same claim in docs/workflow-syntax.md:1201 — Ctrl-D submits or dismisses, Ctrl-C aborts the run like it does everywhere else. Keep the except clause itself (it's correct for exceptions the reader raises), just rename the test to something like test_reader_exception_dismisses_rather_than_crashing_the_dialog and note in its docstring that a real SIGINT goes to the main thread and never exercises this path.

Comment thread CHANGELOG.md Outdated
Windows) submits what has been typed and dismisses the dialog when nothing
has, and Ctrl-C dismisses rather than propagating. The dialog uses `/send`
where the human gate keeps `.`, since a lone `.` is likelier to be prose in
a conversational reply. Off a tty — a pipe, CI, or the web dashboard — the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RECOMMENDED

Two things wrong in this one sentence.

First, the non-tty path did change. The new if user_input == "": continue at dialog.py:282 sits above the tty branch in the shared loop, and Prompt.ask returns "" for a blank line with no default set — confirmed by reading PromptBase.process_response and running it directly. Piping ["", "done"] with isatty() false now gives turns dispatched: 0; on origin/main that blank line was recorded as a DialogMessage, emitted as a dialog_message event, and dispatched to provider.execute_dialog_turn with empty content. The new behavior is fine — the problem is this changelog is the contract for anyone driving a dialog from a pipe or CI, and it tells them nothing changed when their turn count and event stream just did.

Second, the web dashboard isn't "off a tty" in the sense this implies. handle_dialog returns via _web_handle_dialog at line 207, before _display_dialog_start and before the loop — it reaches neither reader, and it's always received whole multi-line messages over the WebSocket. Grouping it with "a pipe, CI" reads as if it hits the Prompt.ask fallback, and someone reading docs/workflow-syntax.md:1201 will conclude the dashboard chat box can't take a multi-line message.

The inline comment at dialog.py:283-284 ("User submitted nothing on a tty") repeats the same mistake and will mislead the next person who touches this code.

Suggested rewording for both the CHANGELOG and the docs bullet:

Off a tty — a pipe or CI — replies are still read one line at a time and /send has no effect; the one change on that path is that a blank line is now skipped instead of dispatched as an empty turn. The web dashboard is unaffected: it takes a separate path that already delivered each message whole.

And widen the code comment:

            if not user_input.strip():
                # Empty submission -- not a turn, and not dismissal either. On a
                # tty this is a bare sentinel line; off a tty it is a blank line
                # from the pipe, which Prompt.ask returns as "".
                continue

Comment thread src/conductor/gates/human.py Outdated
while True:
try:
line = input()
except (EOFError, StopIteration):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RECOMMENDED

The comment here is honest that this except StopIteration exists only to satisfy exhausted unittest.mock side_effect lists — which is exactly why I don't think it belongs in shipped code. It's load-bearing: removing it breaks test_pasted_block_is_one_user_prompt_with_newlines and test_eof_mid_paste_submits_content_not_dismissal with RuntimeError: StopIteration interacts badly with generators and cannot be raised into a Future.

Two problems with leaving it:

  1. The comment's justification is narrower than what the code actually does. input() doesn't originate StopIteration, but it does relay whatever sys.stdin.readline() raises — confirmed with an iterator-backed sys.stdin where a plain input() call propagated it. read_multiline_lines is a plain function, so PEP 479 doesn't help here. Nothing in src/ or plugins/ swaps sys.stdin/input today, so there's no live trigger, but if one ever appears, a broken stdin source would get silently reinterpreted as "the user pressed Ctrl-D" and a truncated turn would go to the model with nothing logged.
  2. It weakens the tests that exist. "The reader read past what the test supplied" — the exact bug class this PR is fixing — now passes as a clean submission instead of failing.

It also makes the new _read_multiline docstring's claim ("behavior is unchanged by the extraction") not quite accurate, since the pre-PR loop only caught EOFError.

# src/conductor/gates/human.py:77-81
        try:
            line = input()
        except EOFError:
            hit_eof = True
            break

And terminate the test doubles explicitly instead of relying on the catch:

# tests/test_gates/test_dialog.py:749
side_effect=["line one", "line two", "line three", "/send", "done", EOFError()],
# tests/test_gates/test_dialog.py:774
side_effect=["ticket text", EOFError(), "done", EOFError()],

Both produce identical assertions against a strict except EOFError:.

Comment thread src/conductor/gates/human.py Outdated


def read_multiline_lines(
console: MarkupFreeConsole, sentinel: str = MULTILINE_SENTINEL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RECOMMENDED

AGENTS.md:161, added by this PR, says: "The two gates use different sentinels, so a caller passes the one it wants rather than relying on the default." A default parameter whose own documentation tells callers not to use it is compensating for a signature that shouldn't allow the wrong call in the first place.

Both production call sites already pass it explicitly (human.py:537, dialog.py:772). The default's only live users are four test call sites in tests/test_gates/test_human.py (766, 782, 794, 798), where it implicitly means "the human gate's sentinel" — exactly the ambiguity splitting the constant was supposed to remove.

The risk isn't hypothetical: read_multiline_lines is public, lives in a module dialog.py already imports from, and read_multiline_lines(console) is silently valid with no signal at compile time. It would quietly truncate a user's prose at any lone . line — data loss, not a crash.

def read_multiline_lines(
    console: MarkupFreeConsole, *, sentinel: str
) -> tuple[str, bool]:

Making it keyword-only also rules out a future (sentinel, console) transposition. The cost is two keyword additions at the production call sites and four in test_human.py, and AGENTS.md can then say "…so sentinel is required rather than defaulted" and mean it.

Comment thread src/conductor/gates/human.py Outdated
"""Read a multi-line answer from stdin (blocking; call on a thread).

Terminates on a line whose stripped text equals ``sentinel`` or on EOF.
Internal newlines are preserved; trailing blank lines are stripped.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RECOMMENDED

Lines 54 and 61-62 both say "trailing blank lines are stripped," but the implementation is "\n".join(lines).rstrip("\n") (line 88), which strips trailing newline characters, not trailing blank lines. Checked directly:

['a', '', '']     -> 'a'        # empty trailing lines: stripped
['a', '   ', '']  -> 'a\n   '   # whitespace-only trailing line: survives

"Blank line" ordinarily includes whitespace-only lines, so this overstates the guarantee — and it's the same gap behind the blocking whitespace issue above. A maintainer who trusts this docstring won't think to add .strip() at a call site that needs it. _read_multiline's docstring at line 534 inherits the same wording.

Either fix the wording in both places:

Internal newlines are preserved; trailing empty lines are dropped, but a trailing line of whitespace is kept verbatim.

or leave the code as-is and just note that changing it to a real .strip()-style rstrip would also eat meaningful trailing indentation from a pasted code block — which is probably the right call, so the docstring should give way instead.

# treated the same as EOF: submit what has been accumulated.
hit_eof = True
break
if line.strip() == sentinel:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RECOMMENDED

Mutating if line.strip() == sentinel: to if line == sentinel: still leaves 138/138 tests passing — every existing test types the sentinel byte-exactly ("." at test_human.py:766, "/send" at :774 and test_dialog.py:749).

A trailing space after /send is a normal keystroke, and terminals and paste buffers add them routinely. Without .strip() here, that line would get silently swallowed into the message body and the user would sit at a prompt that never submits. .strip() predates this PR, but this PR is what makes a five-character typed sentinel the primary way to send a dialog turn, so it's carrying a lot more weight now than before.

Worth flagging the flip side while you're adding coverage: .strip() also means a /send line inside a pasted block (a chat log, a shell transcript) truncates the paste with no warning, and the rest gets eaten as the next turn. /send is a much safer choice than the human gate's ., so this isn't a design objection — but a one-line echo after submission ((sent 3 lines)) would let the user catch it immediately.

@pytest.mark.parametrize("typed", ["/send", " /send", "/send ", "\t/send  "])
def test_sentinel_tolerates_surrounding_whitespace(self, typed: str) -> None:
    with patch("builtins.input", side_effect=["body", typed, "unreachable"]):
        text, hit_eof = read_multiline_lines(MagicMock(), sentinel="/send")
    assert (text, hit_eof) == ("body", False)

The "unreachable" entry also proves the reader stopped at the sentinel instead of just running out of mock values.

Comment thread src/conductor/gates/dialog.py Outdated
# instruct the user to type something with no effect. The markup stays
# in the template because styled() inserts *values* verbatim.
if sys.stdin.isatty():
multiline_hint = (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RECOMMENDED

The banner template mixes + concatenation with adjacent-literal concatenation, and multiline_hint has to stay in sync with hint_args — one {} in the fragment per element in the tuple — with nothing enforcing that.

To be clear about what isn't broken: I rendered both branches and the output is correct today. The trailing " " glues cleanly to "Say ..." via adjacent-literal concatenation, so there's no double or missing space, and the placeholder counts line up.

The concern is the next edit. styled() raises IndexError on a missing argument and silently drops an extra one — both confirmed — and ty can't check a template assembled with +. Add a second {} to the hint without extending the tuple and you get an IndexError that only fires on the tty branch, which never runs in CI unless isatty is patched. Confirming the sentence is even correct today requires the same manual trace I just did, which is a lot of reader effort for not much information.

styled() splices a Text value in with its own spans re-anchored, so the hint can be pre-rendered and dropped into a fixed-arity template instead. Rendered and diffed against current output — byte-identical on both branches, ANSI spans included:

multiline_hint = (
    styled(
        " It can span multiple lines; send it with [bold]{}[/bold] on its own line.",
        DIALOG_SUBMIT_SENTINEL,
    )
    if sys.stdin.isatty()
    else Text("")
)

self.console.print(
    Panel(
        styled(
            "[bold]Agent '{}'[/bold] would like to discuss its output with you.\n"
            "[dim]Type your response below.{} Say [bold]done[/bold] or "
            "[bold]/done[/bold] when finished.[/dim]",
            agent.name,
            multiline_hint,
        ),
        title=Text.from_markup("[bold magenta]Dialog Mode[/bold magenta]"),
        border_style="magenta",
    )
)

Separately, sys.stdin.isatty() now appears both here and at line 768 and has to stay in agreement, or the banner advertises a keystroke the reader ignores — right now that's held together by a comment alone. A shared module-level _reads_multiline_turn() predicate would state the rule once, and the existing patch("conductor.gates.dialog.sys.stdin.isatty") tests keep working unchanged.

provider.execute_dialog_turn.assert_awaited_once()

@pytest.mark.asyncio
async def test_eof_mid_paste_submits_content_not_dismissal(self) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RECOMMENDED

The PR description says each new dialog test "was confirmed to fail against the unpatched gate, so they guard the behavior rather than merely describing it." I checked that by restoring origin/main's _get_user_input body onto DialogHandler via an autouse fixture (no repo files touched) and running TestDialogMultilineInput unmodified:

test_pasted_block_is_one_user_prompt_with_newlines   FAILED  <- genuine
test_eof_mid_paste_submits_content_not_dismissal     PASSED
test_ctrl_d_at_empty_prompt_dismisses                PASSED
test_ctrl_c_dismisses_rather_than_propagating        PASSED
test_web_path_unaffected_by_multiline                PASSED

(Excluding the two banner cases — that harness only reverts _get_user_input, and the [True] banner case does genuinely fail if the banner change is reverted separately.)

Why each one passes on old code: on origin/main the turn went through Prompt.askinput(), so EOFError/KeyboardInterrupt already hit the pre-existing except (EOFError, KeyboardInterrupt): return None and produced the same dismissal. test_eof_mid_paste_submits_content_not_dismissal asserts user_msgs[0].content == "ticket text" and assert_awaited(), both true on old code since Prompt.ask reads exactly that one line. test_web_path_unaffected_by_multiline guards a branch that returns at line 207, before the loop containing read_multiline_lines is ever reached.

Only test_pasted_block_is_one_user_prompt_with_newlines currently holds the line on the actual fix. No production risk here — the risk is to the review record, since three of these are presented as proof and don't actually provide it.

For test_eof_mid_paste_submits_content_not_dismissal, the missing assertion is that the EOF after the text didn't dismiss:

assert result.user_dismissed is False
assert [m.content for m in result.messages if m.role == "user"] == ["ticket text", "done"]

For test_ctrl_d_at_empty_prompt_dismisses, assert that the new reader was actually entered (e.g. that read_multiline_lines was called), so it pins this reader specifically rather than any reader. For the two structural guards, reword the docstrings to say "behavior preserved by the extraction" instead of implying regression coverage, and adjust the claim in the PR description to match.

The multi-line reader drops trailing newlines but deliberately keeps a
whitespace-only line, since a real strip would eat the closing indentation of
a pasted code block. Both new guards compared exactly against "", so a buffer
of spaces or tabs survived as a truthy string and slipped past both.

Two consequences, the second worse than the first. A whitespace-only
submission was dispatched to the model as a turn and spliced into the agent's
re-execution guidance. And an EOF with only whitespace typed did not reach the
"user is leaving, not pasting" branch, so Ctrl-D submitted the whitespace as a
turn and *then* dismissed -- the user asked to leave and sent a message
instead. Nothing was logged on either path.

Both guards now test stripped text, matching _is_dismiss, which already
normalises this way and was the outlier's neighbour in the same file.

Addresses a blocking review finding.
The condition choosing between the multi-line and single-line readers had no
test in either direction: deleting the isatty() half, or the
`prompt_text is None` half, left the whole suite green. The first would
activate the multi-line reader under a pipe or in CI, waiting for a sentinel
nobody can type; the second would make the yes/no confirmation demand /send
after "yes" on every interactive run. Both halves now have a test, and the
Prompt.ask fallback -- previously never executed by any test -- is exercised.

The rule itself moves into a `_reads_multiline_turn()` predicate so the reader
and the banner's sentinel hint consult one source, rather than two isatty()
calls kept in agreement by a comment. The banner's hint is pre-rendered as a
Text and spliced into a fixed-arity template: styled() raises IndexError on a
mismatch and silently drops an extra argument, and the arity was previously
maintained by hand across a `+`-concatenated fragment and its args tuple, on a
branch no test reached. On a tty the rendered output is byte-identical to
before, ANSI spans included. Off a tty the sentence regains the plural it has
upstream ("Type your responses below."), which the previous revision had
silently made singular on the one path this series leaves alone; that banner
is now byte-identical to upstream, and a test pins the wording, since a byte
comparison was otherwise the only thing that would catch it drifting again.

Requiring the sentinel to submit also made the terminal UI's own exit
instruction inert: the banner said "Say done or /done when finished", but on a
tty a dismiss keyword is only seen once the turn is submitted, so `done` alone
left the user at a prompt that never responded -- driving the real handler with
input() returning "done" every time, the dialog never exited in 30 reads. The
failure-recovery notice repeated the same premise, on the one screen where the
user most needs a reliable way out. `_dismiss_instruction()` now states that
rule once beside `_reads_multiline_turn()`, both sites render it, and both
directions are pinned. Off a tty the sentence is unchanged from upstream, since
every line is already a turn there.

Also tightens the extracted reader:

- `sentinel` is keyword-only and required. It is public, the two gates use
  different sentinels, and `read_multiline_lines(console)` was silently valid
  -- it would truncate a user's prose at any lone "." with no signal.
- Only `except EOFError` remains. The extraction had also caught
  StopIteration, which a real stdin never produces here -- an exhausted or
  non-tty stream raises EOFError and a closed one ValueError -- so the catch
  was wider than any reachable input, and it let a test double read past what
  it supplied and still pass as a clean submission. The reason it is not
  caught is recorded at the clause, and a test pins it, so it is not
  reinstated as an oversight.
- The sentinel's whitespace tolerance and the reader's trailing-line handling
  are pinned; both survived mutation before. So is the keyword-only signature
  itself, since restoring one default silently re-opens the hazard and nothing
  else would fail.

`test_ctrl_d_at_empty_prompt_dismisses` now uses a bounded EOF source. A bare
`side_effect=EOFError()` re-raises forever, so deleting the dismissal branch
hung the suite instead of failing it, and there is no pytest-timeout
configured -- a hung CI job rather than a readable failure. That test also
pins `read_on_daemon_thread` as the dispatch: a cancelled asyncio.to_thread
leaves its worker blocked in input() holding a slot in the shared default
executor, which that function's own docstring explains at length.

A pasted block's indentation is pinned end to end, leading edge included: the
guards strip only to decide whether anything was submitted, while the text
itself must reach the provider verbatim, and adding a strip to the returned
turn text previously passed the whole suite while silently reindenting a
pasted code block.

Every guard in this series is mutation-tested: reverting any of them fails at
least one test.

Addresses a blocking review finding plus seven recommended ones.
All three were verified wrong by execution, having been asserted as verified.

"Ctrl-C dismisses rather than propagating" is removed. CPython runs signal
handlers on the main thread only and the read happens on a daemon thread, so a
real SIGINT never reaches that except clause: sending one to a child blocked
in the reader on a pty raises CancelledError at the await and KeyboardInterrupt
escapes asyncio.run. Ctrl-C tears the run down as it does everywhere else, and
a reader who trusted this line would lose their session expecting to keep it.
The except clause is correct for exceptions the reader itself raises and stays;
its test is renamed and its docstring now says explicitly that it does not
cover a real SIGINT.

"Off a tty the single-line path is unchanged" is corrected. Prompt.ask returns
"" for a blank line and the empty-submission guard sits above the tty branch,
so a blank piped line is now skipped where it was previously dispatched as a
turn with empty content. The new behaviour is right, but that path is the
contract for anyone driving a dialog from CI, and it said nothing had changed.

Grouping the web dashboard with "a pipe, CI" is corrected. It returns before
either reader and has always received whole messages, so the old wording
implied its chat box could not take a multi-line reply.

Two things the prose did not say at all are now stated. A terminal accepts an
EOF keystroke only at the start of a line, and its EOF does not persist -- the
read returns and the terminal is readable again -- so Ctrl-D after entering a
line now submits it and a second Ctrl-D is needed to leave, where one used to
exit. On an empty prompt it still exits in one keystroke, so the habitual exit
only changes once something has been entered, and that text is now sent rather
than discarded. And the "trailing blank lines are stripped" overstatement is
dropped: a whitespace-only trailing line is kept verbatim, which is deliberate
-- stripping it would eat a pasted code block's closing indentation -- and is
now pinned by a test.

Addresses three recommended review findings.
@throup

Copy link
Copy Markdown
Contributor Author

Thank you — this was an unusually careful review, and it caught a real bug plus three false claims in my own write-up. I reproduced every finding before acting on it; all twelve stood up, including your mutation-test counts (138/138 against either half of the reader gate). Everything is addressed.

Blocking

Whitespace bypassing the empty-submission guard. Confirmed exactly as described, including the worse half: " " then Ctrl-D submitted the whitespace and then dismissed. Both guards now compare stripped text, and the docstring says "nothing but whitespace accumulated". Two regression tests, including side_effect=[" ", EOFError()] asserting assert_not_awaited().

The untested isatty() reader gate. Confirmed — 138 passed with either half deleted. Both of your suggested tests are in, near-verbatim. I also took the _reads_multiline_turn() predicate you proposed further down, so the rule is stated once instead of held together by a comment; existing tests patching conductor.gates.dialog.sys.stdin.isatty keep working unchanged.

The three false claims

These are the ones I'm most grateful for, since I had asserted them as verified.

Ctrl-C. You're right, and I reproduced it your way — real SIGINT to a child blocked in the reader on a pty gave CancelledError at the await and KeyboardInterrupt out of asyncio.run. The claim is gone from the CHANGELOG and docs, the except clause stays, and the test is renamed to test_reader_exception_dismisses_rather_than_crashing_the_dialog with a docstring stating that a real SIGINT goes to the main thread and never exercises it.

The non-tty path did change. Measured both sides: upstream/main dispatched a blank piped line as a turn with empty content, this skips it. Both the CHANGELOG and the docs now say so, using your wording, and there's a non-tty test for it.

The three tests that pass against unpatched code. Reproduced your table — 1 of 5. Worth recording how I got this wrong, since my description presented it as evidence: my original check reverted the whole dialog.py, which also removed its import sys, so three tests died on patch(...sys.stdin.isatty) with AttributeError. A harness failure that looked like a behavioural one, and I read it as confirmation. Your method — reverting only _get_user_input — is the right one. The two structural guards now say in their docstrings that they pin behaviour preserved by the extraction, test_eof_mid_paste... has your two stronger assertions, and the PR description is corrected rather than quietly edited.

Recommended — all taken

  • StopIteration handler removed. You're right that input() relays it from a broken stdin (I confirmed with an iterator-backed sys.stdin), so the narrower comment was hiding a wider effect. Now a strict except EOFError:, with the test doubles terminated by explicit EOFError() as you suggested.
  • Empty-guard coverage. Your test is in, including the "loop continued" half.
  • The "bounded list" comment that wasn't. Replaced with a genuinely bounded EOF source, so deleting the Ctrl-D guard now fails after 10 reads instead of hanging. That mattered more than it looked: it was the one mutation the suite couldn't report at all.
  • sentinel keyword-only and required. Done; AGENTS.md now says "keyword-only and required" and means it.
  • Sentinel whitespace tolerance. Your parametrised test is in, "unreachable" entry included.
  • The "trailing blank lines" docstring. Took your second option — the wording gives way, and the docstring now says a trailing whitespace line is kept verbatim because a real strip would eat a pasted code block's closing indentation. Pinned by a test.
  • The banner's unenforced arity. Adopted your pre-rendered-Text version. I diffed the rendered output on both branches including ANSI spans: byte-identical.

A bug of my own, found while re-checking this

Requiring /send to submit made the terminal UI's own exit instruction inert, and I had not noticed. The banner said "Say done or /done when finished" — but a dismiss keyword is only recognised once a turn is submitted, so on a tty done alone left the user at a prompt that never responded. Driving the real handler with input() returning "done" every time, the dialog never exited in 30 reads. The failure-recovery notice repeated the same premise, on the one screen where a user most wants a reliable way out.

This was the worse half of the change: I added the /send hint to that panel and never audited the sentence beside it. _dismiss_instruction() now states the rule once next to _reads_multiline_turn(), both sites render it, and both directions are pinned — reverting the banner to the old wording now fails a test. Off a tty the sentence is byte-identical to main's, since every line is already a turn there.

StopIteration not being caught was unpinned. Re-adding it to the except passed the whole suite, while the commit message singled the removal out for a paragraph and then claimed every guard in the series was mutation-tested. Now pinned, so the claim holds.

Two smaller things, also mine

The banner's non-tty wording had silently changed from your original "Type your responses below." (plural) to the singular, on the one path this PR describes as untouched. Restored — the non-tty banner now renders byte-identically to upstream/main, ANSI included, and the singular is used only on the tty branch where "It can span multiple lines" follows it.

Ctrl-D after typing something now takes two presses, and the first one sends your draft. A terminal's EOF does not persist, so on main one Ctrl-D ended the dialog; now it submits and returns to a fresh prompt. That is the direct price of "an EOF terminating a paste submits it", so I have kept the behaviour — but it was undocumented, which is the real defect. Both the CHANGELOG and the docs now say it, and note that Ctrl-D on an empty prompt still exits in one keystroke.

StopIteration is no longer caught at all, rather than caught wide. You were right that the old comment's justification was narrower than the code's effect. Narrowing it to except EOFError alone turned out to have a second-order cost I had not measured: because the reader runs on a daemon thread whose result arrives through an asyncio.Future, a StopIteration reaching that boundary becomes an opaque RuntimeError that tears the run down. I checked what real stdin objects actually raise — EOFError when exhausted, ValueError when closed, never StopIteration — so it is unreachable outside a contrived stdin replacement. The clause now carries a comment saying exactly that, so it is not reinstated as an oversight.

Two more guards pinned. The non-tty banner wording and the choice of read_on_daemon_thread over asyncio.to_thread both survived mutation. The first has already drifted once in this PR's own history, so it now has an assertion rather than relying on a byte comparison; the second matters because a cancelled to_thread leaves a worker blocked in input() holding a slot in the shared default executor, which is documented at length on that helper.

A pasted block containing a lone /send line still truncates silently, with no escape. Pre-existing and structurally identical to the human gate's ., and /send makes the collision far less likely, so I have not changed it — but you raised the same point, and I agree the (sent N lines) echo you suggested would let a user catch it immediately. Happy to add that here if you want it, or leave it as a follow-up.

One I'd rather leave to you

pytest-timeout. I've done the local half — the bounded EOF source turns that hang into a failure, and I checked it answers your actual concern: across nineteen mutations of this diff, none now produces a hang, and the one that previously did fails in under five seconds. I stopped short of adding the dependency and a suite-wide default because .github/workflows/ci.yml:158-165 documents a deliberate job-level strategy (timeout-minutes: 20, sized against Windows running the suite at roughly twice Linux wall time), and a global per-test cap is a different mechanism that could make the Textual pilot tests flaky on a noisy runner. It reads like a repo-wide call rather than something this PR should decide. Happy to add it here if you'd like it — say what default you'd want — or leave it as a follow-up.

Still open from the original description

The /send-vs-. sentinel question is unresolved and I have no strong claim on it; the alternatives are listed in the description and it's a one-line change either way.

@jrob5756 Jason Robert (jrob5756) left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for contributing!

@codecov-commenter

Codecov Comments Bot (codecov-commenter) commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@b2ad333). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #510   +/-   ##
=======================================
  Coverage        ?   91.93%           
=======================================
  Files           ?      164           
  Lines           ?    26735           
  Branches        ?        0           
=======================================
  Hits            ?    24580           
  Misses          ?     2155           
  Partials        ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Jason Robert added 2 commits September 9, 2026 08:45
@jrob5756
Jason Robert (jrob5756) merged commit 3becc66 into microsoft:main Sep 9, 2026
13 checks passed
Chris Throup (throup) added a commit to too-good-to-go/conductor that referenced this pull request Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dialog mode cannot accept a multi-line reply: a pasted block becomes one turn per line

3 participants